Defer the data-parallel gradient all-reduce to update() under gradient accumulation - #5099
Defer the data-parallel gradient all-reduce to update() under gradient accumulation#5099NuojCheng wants to merge 2 commits into
Conversation
MaxTextTrainingEngine all-reduced the whole gradient tree across data replicas once per *micro*-batch. Only the sum matters, so at gradient_accumulation N it paid N times for a reduction that is correct once. Tag the parameters `reduced` over the data axis where `value_and_grad` differentiates them, and their cotangents come out `unreduced`: a per-replica partial that accumulates locally across micro-batches. `update()` reshards back to a plain spec, which is what emits the single all-reduce -- before the division, the norm and the optimizer, so nothing downstream has to know about the tag. This is what gradient_accumulation.py already does for the pre-train path, applied across the engine's separate jax.jit dispatches rather than inside one lax.scan. Gated to explicit sharding on an all-Explicit mesh where "data" is the only batch axis of size > 1. The last condition is not conservatism: with fsdp on the batch too, JAX rejects the backward pass, because the unreduced set has to be exactly the contracted axes and widening it to fsdp collides with the parameters being sharded there. Two places had to learn that gradients can be tagged. RMSNorm's scale alignment indexed `spec[...]`, which a tagged spec refuses -- it reads `spec.partitions` now, as the pre-train path's does. And an accumulator can outlive the shardings it was built under: a checkpoint holds the reduced total (Orbax cannot serialize an unreduced array at all), and a recompile can flip the deferral, so both restore and recompile move it back onto whatever the kernels now expect. qwen3-0.6b, 4x v6e, data=4 fsdp=1, micro-batch 8x1024, median steady-state step: ga=8 584.3ms -> 428.3ms (1.36x) ga=4 295.4ms -> 229.8ms (1.29x) ga=1 80.2ms -> 80.1ms (unchanged, as it should be) The optimized HLO says the same thing exactly: 596M f32 elements all-reduced per micro-batch became 596M once per optimizer step, and the micro-batch kernels are left with two scalars, the loss and its denominator.
There was a problem hiding this comment.
Code Review
This pull request introduces a deferred data-parallel gradient all-reduce optimization for gradient accumulation in the MaxText training engine. By tagging differentiated parameters as reduced over the data axis, gradient accumulation remains replica-local, and the cross-replica all-reduce is deferred to run once per optimizer step rather than once per micro-batch. The changes also include proper handling of these tags during checkpoint saving/restoration and layer normalization. The review feedback highlights two important improvements: first, using a more robust utility to detect if the data axis is sharded to avoid failures with nested tuple partitions, and second, adding a safety check in batch_mesh_axes to prevent an IndexError when dealing with empty partition specs.
| A tensor already sharded over that axis is returned untouched: it holds no cross-replica | ||
| partial to defer, and JAX rejects a spec that both shards and reduces over one axis. | ||
| """ | ||
| if _DATA_AXIS in sharding.mesh_axes_for_dim(named_sharding.spec.partitions): |
There was a problem hiding this comment.
Using sharding.mesh_axes_for_dim on named_sharding.spec.partitions will fail to detect _DATA_AXIS if it is nested inside a tuple (e.g., when a dimension is sharded over multiple axes like ('data', 'model')). This can lead to JAX rejecting the spec at runtime because it thinks the axis is not already sharded.
Using the existing helper sharding.get_mesh_axes_used_by_tensor_spec is much more robust as it correctly flattens the PartitionSpec and checks all used axes.
| if _DATA_AXIS in sharding.mesh_axes_for_dim(named_sharding.spec.partitions): | |
| if _DATA_AXIS in sharding.get_mesh_axes_used_by_tensor_spec(named_sharding.spec): |
| if spec is None: | ||
| return frozenset() |
There was a problem hiding this comment.
If spec.partitions is empty (e.g., for a 0-D PartitionSpec), accessing spec.partitions[0] will raise an IndexError. Although the caller in maxtext_engine.py catches this exception, batch_mesh_axes is a public utility function in sharding.py and should be robust on its own to prevent unexpected crashes if called elsewhere.
| if spec is None: | |
| return frozenset() | |
| if spec is None or not spec.partitions: | |
| return frozenset() |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
The gate only refused meshes where a second axis shared the *batch* dimension, which
caught `fsdp` and missed `tensor`. Tensor parallelism reaches the same contradiction
through the feature dimension instead: qwen3-0.6b at dp2 x tp2 on 4x v6e dies on the
first micro-batch with
ShardingTypeError: out_sharding's unreduced axes should be equal to the contracting
specs. Got unreduced axes=frozenset({'data'}) and contracting spec=('data', None,
'tensor')
and would have kept dying for every other axis that shards something contracted --
`expert`, `context`, `tensor_sequence`. Enumerating them is how `tensor` was missed in
the first place, so the rule is now the blunt one: "data" alone above size 1, or no
deferral. Nothing is lost that worked before, since none of those meshes ran.
Stacked on #5088 (base
ga-bench-5060).What
MaxTextTrainingEngineall-reduces the whole gradient tree across data replicas once per micro-batch. Only the sum over the whole optimizer step matters, so atgradient_accumulation = Nit paysNtimes for a reduction that is correct once.This tags the parameters
reducedover thedataaxis right wherejax.value_and_graddifferentiates them. Their cotangents then come outunreduced— a per-replica partial sum that accumulates replica-locally across micro-batches — andupdate()reshards back to a plain spec, which is what emits the single cross-replica all-reduce.It is the same trick
gradient_accumulation.pyalready plays for the pre-train path, applied across the engine's separatejax.jitdispatches rather than inside onelax.scan.Results — qwen3-0.6b on 4× v6e
data=4, fsdp=1, micro-batch8×1024,shard_mode=explicit, SGD, no clipping,remat=none. Median steady-state step time over 20 post-warmup steps, untraced:Where it went, at GA=8:
fwd_bwddrops 59.6 → 37.4 ms per micro-batch, andupdatedoes not pay it back on the host clock (82.9 → 60.8 ms). These two are host-side dispatch medians — the engine's calls are asynchronous, so they carry queue backpressure and do not sum to the step time; the end-to-end median in the table is the number to trust. They locate the win, they don't account for it.The optimized HLO for the real model is unambiguous:
first_kernel(per micro-batch)accum_kernel(per micro-batch)_update_kernel(per step)Losses agree to within 4.5e-5 relative across all 23 steps of every A/B pair (max over GA ∈ {1, 4, 8}) — float32 reassociation of the same sum, since the cross-replica addition moves from before the micro-batch sum to after it. On a 4-device CPU mesh, where the reassociation is exact, they are bit-identical.
When it engages
_deferred_all_reduce_shardingsreturns(None, None)— the untagged status quo — unless all of:shard_mode == EXPLICIT;Explicit(a caller can hand the engine a barejax.sharding.Mesh(...)regardless ofshard_mode, and the tags are rejected onAutoaxes);datais the only mesh axis of size > 1 thatactivation_batchresolves to.(3) is not conservatism. A gradient contracts over the batch and JAX requires the unreduced set to be exactly the contracted axes, so with
fsdpon the batch too it rejects the backward pass outright:and widening the tag to
fsdpis not available either, since the parameters are sharded over it. Verified on adata=2 × fsdp=2mesh.What else had to change
layers/normalizations.py—_align_scale_with_normalized_axisindexedspec[...], which a tagged spec refuses (ValueError: Using pspec[...] is dangerous when PartitionSpec has non-empty unreduced/reduced set). It readsspec.partitionsnow and carries the tags onto the new spec. Every run crashed here before this; it is the same fix the pre-train path needed.utils/sharding.py— newbatch_mesh_axes(mesh, rules)for condition (3).device_indices_mapis undefined for one), and a recompile can flip the deferral on or off. Sosave_checkpointreduces on the way out, and bothrestore_checkpointand_compile_for_batchmove a live accumulator back onto whatever the kernels now expect. Both directions are exact: resharding away fromunreducedruns the pending all-reduce, anddevice_putonto it leaves the value on one replica and zeros the rest, so the deferred all-reduce reproduces it._accumulated_denominatoris deliberately left plain —float()on an unreduced scalar raises, and its per-micro-batch all-reduce is a singlef32[].Second commit: the gate missed tensor parallelism
Found while benchmarking #5104 against a pure-TP mesh. The gate refused a second mesh axis on the batch dimension, which catches
fsdp.tensorgets to the same contradiction through the feature dimension of the same activation, which a batch-axis check cannot see, so qwen3-0.6b at dp2 × tp2 on 4× v6e died on the first micro-batch:expert,contextandtensor_sequenceshard contracted dimensions too and would have gone the same way. Enumerating the axes known to break is howtensorwas missed, so the rule is now the blunt one —dataalone above size 1, or no deferral. Nothing that worked before is lost, since none of those meshes ran.Two tests come with it: the gate declines at dp2 × tp2 (fails without the fix), and a tensor-parallel mesh completes a step and moves its weights with the tag off. The second does not reproduce the crash — the toy model shards plenty over
tensorand still traces clean on CPU; what raised the error was qwen3-0.6b's attention kernels on TPU, verified there before and after.Tests
tests/post_training/unit/maxtext_engine_deferred_all_reduce_test.py, 13 cases on a 4-device CPU mesh with a real (tiny) MaxText decoder:fsdpon the batch, withtensoron the features, and for a parameter already sharded overdata;update()'s does;Existing
maxtext_engine_test.py+maxtext_engine_constructor_test.py: 54 passed.Follow-ups, not in scope here
shard_optimizer_over_datais read only bygradient_accumulation.py, which the engine does not go through. It is orthogonal to this change — the win above needs no ZeRO-1 — but wiring it into the engine is a separate and much larger piece of work.tunix_adapter.py:67's process-widewith_sharding_constraint→reshardmonkeypatch.perf_parityrig from Match Tunix peft_trainer_v2 performance in MaxTextTrainingEngine #5060 (--dp,--shard-mode,--no-defer) plus building the mesh withmaxtext_utils.get_mesh_from_configinstead of a barejax.sharding.Mesh, which is what actually setsAxisType.Explicit. Those files are not on this branch, so the change is not included here; the command waspython qwen3_engine_profile.py --ga 8 --dp 4 --fsdp 1 --shard-mode explicit --no-trace [--no-defer].